Skip to content

feat: x402BatchSettlement contract#1950

Draft
CarsonRoscoe wants to merge 22 commits intox402-foundation:mainfrom
coinbase:feat/evm-contracts-batch-settlement
Draft

feat: x402BatchSettlement contract#1950
CarsonRoscoe wants to merge 22 commits intox402-foundation:mainfrom
coinbase:feat/evm-contracts-batch-settlement

Conversation

@CarsonRoscoe
Copy link
Copy Markdown
Contributor

@CarsonRoscoe CarsonRoscoe commented Apr 7, 2026

Description

Implements x402BatchSettlement, the onchain escrow contract powering the batch-settlement x402 payment scheme. This scheme is designed for high-frequency API access, clients pre-fund a subchannel, sign off-chain cumulative vouchers per request, and the server batch-claims them onchain at its discretion. No per-request gas cost.

Deployed to Base Sepolia (0x40200e6f073aCB938e0Cf766B83f4E5286E60003) for parallel SDK development.

Contract Overview

The contract manages two layers: a service registry (one record per server) and subchannels (one per (serviceId, payer, token) triple).

Service lifecycle

Servers register a serviceId first-come-first-serve, specifying an initial payTo address, an initial authorizer, and a withdrawWindow (bounded 15 min – 30 days). Registration can be done directly or gaslessly via an EIP-712 signed registerFor. Admin operations (add/remove authorizer, update payTo, update withdraw window) are all gaslessly submittable — signed by an existing authorizer and consumed with an adminNonce to prevent replay. At least one authorizer must always remain.

Subchannel lifecycle

A subchannel is identified by (serviceId, payer, token), making services token-agnostic — any ERC-20 can be deposited. Three gasless deposit methods are supported:

  • EIP-3009 (receiveWithAuthorization) — ideal for USDC, fully off-chain
  • Permit2 (permitWitnessTransferFrom) — works for any ERC-20 with Permit2 approval, with a DepositWitness binding the deposit to a specific service
  • EIP-2612 + Permit2 — two signatures, one call; non-fatal permit failure falls through if approval already exists

Payment flow

Clients sign EIP-712 Voucher messages per request. Each voucher carries a monotonically increasing nonce and a cumulative cumulativeAmount. The server accumulates these off-chain and batches them into a single claim(serviceId, token, VoucherClaim[]) call. Claimed amounts accumulate in unsettled[serviceId][token] and are transferred to payTo via a separate settle(serviceId, token). The split lets servers amortize gas across arbitrarily many payers.

Voucher signature verification

Signatures are verified by pure ECDSA recovery — no EIP-1271. Smart contract wallets are supported via client signer delegation: a payer authorizes an EOA hot wallet to sign on their behalf (authorizeClientSigner / authorizeClientSignerFor). Delegation is per-service and uses a clientNonce for gasless replay protection.

Withdrawals

Three exit paths:

  • Cooperative (instant): Server signs an EIP-712 CooperativeWithdraw as authorizer; unclaimed deposit is refunded immediately, can batch multiple payers.
  • Gasless non-cooperative: Payer signs RequestWithdrawal (includes withdrawNonce to prevent replay after cooperative withdraw); facilitator submits requestWithdrawalFor. After the window, anyone calls withdraw.
  • Direct non-cooperative: Payer calls requestWithdrawal directly.

Both reset paths preserve the voucher nonce so old vouchers cannot be replayed after re-deposit. The withdrawNonce increments on each cooperative withdraw to prevent authorizer signature replay.

Tests

Test Results — 174/174 passed, 0 failed

Test Suite Type Tests Result
X402BatchSettlementTest Unit 98 ✅ all pass

Coverage — Production Contracts

Contract Lines Statements Branches Functions
x402BatchSettlement.sol 100% (245/245) 100% (292/292) 100% (54/54) 100% (35/35)

x402BatchSettlement is the primary new contract and hits 100% on all four coverage axes

Checklist

  • I have formatted and linted my code
  • All new and existing tests pass
  • My commits are signed (required for merge) -- you may need to rebase if you initially pushed unsigned commits

@github-actions github-actions bot added the specs Spec changes or additions label Apr 7, 2026
@CarsonRoscoe CarsonRoscoe changed the title Feat/evm contracts batch settlement feat: x402BatchSettlement contract Apr 7, 2026
Copy link
Copy Markdown

@ilikesymmetry ilikesymmetry left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

First pass gut says lots of code and would like to trim down and simplify where possible. Going to take more passes.

@ilikesymmetry
Copy link
Copy Markdown

Directionally, I think we should move towards more statelessness like this: https://gist.github.com/ilikesymmetry/b351bd6ca47a9419c91b195bce332952

@phdargen phdargen self-assigned this Apr 8, 2026
@vercel
Copy link
Copy Markdown

vercel bot commented Apr 9, 2026

@CarsonRoscoe is attempting to deploy a commit to the Coinbase Team on Vercel.

A member of the Team first needs to authorize it.

Copy link
Copy Markdown

@ilikesymmetry ilikesymmetry left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Multiple security issues needing resolution before proceeding to audit!

struct VoucherClaim {
Voucher voucher;
bytes signature;
uint128 claimAmount;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

now that we support authorizers signing, this probably needs to be totalClaimed to protect against replays

Comment on lines +79 to +80
bytes32 public constant REFUND_TYPEHASH =
keccak256("Refund(bytes32 channelId)");
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

needs replay protection in case channel redeposited

Comment on lines +82 to +83
bytes32 public constant CLAIM_BATCH_TYPEHASH =
keccak256("ClaimBatch(bytes32 claimsHash)");
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

would prefer to have full types here as this feels like a hack

Comment on lines +98 to +113
event ChannelCreated(bytes32 indexed channelId, ChannelConfig config);
event Deposited(
bytes32 indexed channelId,
uint128 amount,
uint128 newBalance
);
event Claimed(
bytes32 indexed channelId,
uint128 claimAmount,
uint128 newTotalClaimed
);
event Settled(
address indexed receiver,
address indexed token,
uint128 amount
);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

add msg.sender

public receivers;

// =========================================================================
// Events
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

missing refund event

Comment on lines +456 to +460
(address recovered, ECDSA.RecoverError err, ) = ECDSA
.tryRecoverCalldata(digest, vc.signature);
if (err != ECDSA.RecoverError.NoError || recovered != payerAuth) {
revert InvalidSignature();
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

feel like we should just use recover instead of try given we just revert anyways?

Comment on lines +496 to +498
function _verifyReceiverAuthorizer(
bytes32 typehash,
bytes32 data,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

don't feel like we need this function at all. Just compute digest on outer layer and do isValidSignatureNow check where we need it

using SafeERC20 for IERC20;

/// @inheritdoc IDepositCollector
function collect(
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CRITICAL: This function is permissionless and lets anyone choose the recipient, letting anyone frontrun transactions and steal funds. This needs to be bound to the BatchSettlement singleton and only forward funds there and also lock down the caller to the singleton too. Same for all other collectors and would recommend making an abstract DepositCollector for this purpose.

/// @dev Enables a single-tx deposit for tokens that implement EIP-2612, without requiring the payer
/// to have previously approved Permit2. The EIP-2612 permit call is soft-fail (try/catch) so that
/// pre-existing approvals or replayed permits don't revert the entire deposit.
contract Permit2WithERC2612DepositCollector is Permit2DepositCollectorBase {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

feel like we should just merge this with other Permit2 collector and do a pattern like this for handling the optional 2612 approval. Would let us go down from 3->1 contracts for the use case and only add a single conditional block to the implementation. Seems net simpler.

Comment on lines +53 to +56
struct Voucher {
ChannelConfig channel;
uint128 maxClaimableAmount;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't love how much calldata there is here. I think there's an optimization we could make but will save it until we fix the security issues.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

specs Spec changes or additions

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants